2a7e5d8e51489bdcc5e2dc20bc3656474201708e
[nextcloud-desktop.git] /
1 /*
2  * Copyright (C) 2023 by Claudio Cambra <claudio.cambra@nextcloud.com>
3  *
4  * This program is free software; you can redistribute it and/or modify
5  * it under the terms of the GNU General Public License as published by
6  * the Free Software Foundation; either version 2 of the License, or
7  * (at your option) any later version.
8  *
9  * This program is distributed in the hope that it will be useful, but
10  * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
11  * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
12  * for more details.
13  */
14
15 import FileProvider
16 import Foundation
17 import NCDesktopClientSocketKit
18 import NextcloudKit
19 import NextcloudFileProviderKit
20 import OSLog
21
22 let AuthenticationTimeouts: [UInt64] = [ // Have progressively longer timeouts to not hammer server
23     3_000_000_000, 6_000_000_000, 30_000_000_000, 60_000_000_000, 120_000_000_000, 300_000_000_000
24 ]
25
26 extension FileProviderExtension: NSFileProviderServicing, ChangeNotificationInterface {
27     /*
28      This FileProviderExtension extension contains everything needed to communicate with the client.
29      We have two systems for communicating between the extensions and the client.
30
31      Apple's XPC based File Provider APIs let us easily communicate client -> extension.
32      This is what ClientCommunicationService is for.
33
34      We also use sockets, because the File Provider XPC system does not let us easily talk from
35      extension->client.
36      We need this because the extension needs to be able to request account details. We can't
37      reliably do this via XPC because the extensions get torn down by the system, out of the control
38      of the app, and we can receive nil/no services from NSFileProviderManager. Once this is done
39      then XPC works ok.
40     */
41     func supportedServiceSources(
42         for itemIdentifier: NSFileProviderItemIdentifier,
43         completionHandler: @escaping ([NSFileProviderServiceSource]?, Error?) -> Void
44     ) -> Progress {
45         Logger.desktopClientConnection.debug("Serving supported service sources")
46         let clientCommService = ClientCommunicationService(fpExtension: self)
47         let fpuiExtService = FPUIExtensionServiceSource(fpExtension: self)
48         let services: [NSFileProviderServiceSource] = [clientCommService, fpuiExtService]
49         completionHandler(services, nil)
50         let progress = Progress()
51         progress.cancellationHandler = {
52             let error = NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError)
53             completionHandler(nil, error)
54         }
55         return progress
56     }
57
58     @objc func sendFileProviderDomainIdentifier() {
59         let command = "FILE_PROVIDER_DOMAIN_IDENTIFIER_REQUEST_REPLY"
60         let argument = domain.identifier.rawValue
61         let message = command + ":" + argument + "\n"
62         socketClient?.sendMessage(message)
63     }
64
65     private func signalEnumeratorAfterAccountSetup() {
66         guard let fpManager = NSFileProviderManager(for: domain) else {
67             Logger.fileProviderExtension.error(
68                 "Could not get file provider manager for domain \(self.domain.displayName, privacy: .public), cannot notify after account setup"
69             )
70             return
71         }
72
73         assert(ncAccount != nil)
74
75         fpManager.signalErrorResolved(NSFileProviderError(.notAuthenticated)) { error in
76             if error != nil {
77                 Logger.fileProviderExtension.error(
78                     "Error resolving not authenticated, received error: \(error!.localizedDescription)"
79                 )
80             }
81         }
82
83         Logger.fileProviderExtension.debug(
84             "Signalling enumerators for user \(self.ncAccount!.username) at server \(self.ncAccount!.serverUrl, privacy: .public)"
85         )
86
87         notifyChange()
88     }
89
90     func notifyChange() {
91         guard let fpManager = NSFileProviderManager(for: domain) else {
92             Logger.fileProviderExtension.error(
93                 "Could not get file provider manager for domain \(self.domain.displayName, privacy: .public), cannot notify changes"
94             )
95             return
96         }
97
98         fpManager.signalEnumerator(for: .workingSet) { error in
99             if error != nil {
100                 Logger.fileProviderExtension.error(
101                     "Error signalling enumerator for working set, received error: \(error!.localizedDescription, privacy: .public)"
102                 )
103             }
104         }
105     }
106
107     @objc func setupDomainAccount(
108         user: String, userId: String, serverUrl: String, password: String
109     ) {
110         Task {
111             let authTestNcKit = NextcloudKit()
112             authTestNcKit.setup(user: user, userId: userId, password: password, urlBase: serverUrl)
113             var authAttemptState = AuthenticationAttemptResultState.connectionError // default
114
115             // Retry a few times if we have a connection issue
116             for authTimeout in AuthenticationTimeouts {
117                 authAttemptState = await authTestNcKit.tryAuthenticationAttempt()
118                 guard authAttemptState == .connectionError else { break }
119
120                 Logger.fileProviderExtension.info(
121                     "\(user, privacy: .public) authentication try timed out. Trying again soon."
122                 )
123                 try? await Task.sleep(nanoseconds: authTimeout)
124             }
125
126             switch (authAttemptState) {
127             case .authenticationError:
128                 Logger.fileProviderExtension.info(
129                     "\(user, privacy: .public) authentication failed due to bad creds, stopping"
130                 )
131                 return
132             case .connectionError:
133                 // Despite multiple connection attempts we are still getting connection issues.
134                 // Connection error should be provided
135                 Logger.fileProviderExtension.info(
136                     "\(user, privacy: .public) authentication try failed, no connection."
137                 )
138                 return
139             case .success:
140                 Logger.fileProviderExtension.info(
141                 """
142                 Authenticated! Nextcloud account set up in File Provider extension.
143                 User: \(user, privacy: .public) at server: \(serverUrl, privacy: .public)
144                 """
145                 )
146             }
147
148             Task { @MainActor in
149                 let newNcAccount =
150                     Account(user: user, id: userId, serverUrl: serverUrl, password: password)
151                 guard newNcAccount != ncAccount else { return }
152                 ncAccount = newNcAccount
153                 ncKit.setup(
154                     account: newNcAccount.ncKitAccount,
155                     user: newNcAccount.username,
156                     userId: newNcAccount.id,
157                     password: newNcAccount.password,
158                     urlBase: newNcAccount.serverUrl,
159                     userAgent: "Nextcloud-macOS/FileProviderExt",
160                     nextcloudVersion: 25,
161                     delegate: nil) // TODO: add delegate methods for self
162                 
163                 changeObserver = RemoteChangeObserver(
164                     remoteInterface: ncKit, changeNotificationInterface: self, domain: domain
165                 )
166                 ncKit.setup(delegate: changeObserver)
167                 signalEnumeratorAfterAccountSetup()
168             }
169         }
170     }
171
172     @objc func removeAccountConfig() {
173         Logger.fileProviderExtension.info(
174             "Received instruction to remove account data for user \(self.ncAccount!.username, privacy: .public) at server \(self.ncAccount!.serverUrl, privacy: .public)"
175         )
176         ncAccount = nil
177     }
178
179     func updatedSyncStateReporting(oldActions: Set<UUID>) {
180         actionsLock.lock()
181
182         guard oldActions.isEmpty != syncActions.isEmpty else {
183             actionsLock.unlock()
184             return
185         }
186
187         let command = "FILE_PROVIDER_DOMAIN_SYNC_STATE_CHANGE"
188         var argument: String?
189         if oldActions.isEmpty, !syncActions.isEmpty {
190             argument = "SYNC_STARTED"
191         } else if !oldActions.isEmpty, syncActions.isEmpty {
192             argument = errorActions.isEmpty ? "SYNC_FINISHED" : "SYNC_FAILED"
193             errorActions = []
194         }
195         
196         actionsLock.unlock()
197
198         guard let argument else { return }
199         Logger.fileProviderExtension.debug("Reporting sync \(argument)")
200         let message = command + ":" + argument + "\n"
201         socketClient?.sendMessage(message)
202     }
203 }